/** * Copyright 2014 Confluent Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.confluent.rest; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.jaxrs.base.JsonParseExceptionMapper; import org.eclipse.jetty.jaas.JAASLoginService; import org.eclipse.jetty.jmx.MBeanContainer; import org.eclipse.jetty.security.ConstraintMapping; import org.eclipse.jetty.security.ConstraintSecurityHandler; import org.eclipse.jetty.security.DefaultIdentityService; import org.eclipse.jetty.security.SecurityHandler; import org.eclipse.jetty.security.authentication.BasicAuthenticator; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.NetworkTrafficServerConnector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.DefaultHandler; import org.eclipse.jetty.server.handler.HandlerCollection; import org.eclipse.jetty.server.handler.RequestLogHandler; import org.eclipse.jetty.server.handler.StatisticsHandler; import org.eclipse.jetty.servlet.DefaultServlet; import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlets.AsyncGzipFilter; import org.eclipse.jetty.servlets.CrossOriginFilter; import org.eclipse.jetty.util.resource.ResourceCollection; import org.eclipse.jetty.util.security.Constraint; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.server.validation.ValidationFeature; import org.glassfish.jersey.servlet.ServletContainer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.management.ManagementFactory; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import javax.servlet.DispatcherType; import javax.ws.rs.core.Configurable; import io.confluent.common.config.ConfigException; import io.confluent.common.metrics.JmxReporter; import io.confluent.common.metrics.MetricConfig; import io.confluent.common.metrics.Metrics; import io.confluent.common.metrics.MetricsReporter; import io.confluent.rest.exceptions.ConstraintViolationExceptionMapper; import io.confluent.rest.exceptions.GenericExceptionMapper; import io.confluent.rest.exceptions.WebApplicationExceptionMapper; import io.confluent.rest.logging.Slf4jRequestLog; import io.confluent.rest.metrics.MetricsResourceMethodApplicationListener; import io.confluent.rest.validation.JacksonMessageBodyProvider; /** * A REST application. Extend this class and implement setupResources() to register REST * resources with the JAX-RS server. Use createServer() to get a fully-configured, ready to run * Jetty server. */ public abstract class Application<T extends RestConfig> { protected T config; protected Server server = null; protected CountDownLatch shutdownLatch = new CountDownLatch(1); protected Metrics metrics; private static final Logger log = LoggerFactory.getLogger(Application.class); public Application(T config) { this.config = config; MetricConfig metricConfig = new MetricConfig() .samples(config.getInt(RestConfig.METRICS_NUM_SAMPLES_CONFIG)) .timeWindow(config.getLong(RestConfig.METRICS_SAMPLE_WINDOW_MS_CONFIG), TimeUnit.MILLISECONDS); List<MetricsReporter> reporters = config.getConfiguredInstances(RestConfig.METRICS_REPORTER_CLASSES_CONFIG, MetricsReporter.class); reporters.add(new JmxReporter(config.getString(RestConfig.METRICS_JMX_PREFIX_CONFIG))); this.metrics = new Metrics(metricConfig, reporters, config.getTime()); } /** * Register resources or additional Providers, ExceptionMappers, and other JAX-RS components with * the Jersey application. This, combined with your Configuration class, is where you can * customize the behavior of the application. */ public abstract void setupResources(Configurable<?> config, T appConfig); /** * Returns a list of static resources to serve using the default servlet. * * <p>For example, static files can be served from class loader resources by returning * {@code * new ResourceCollection(Resource.newClassPathResource("static")); * } * * <p>For those resources to get served, it is necessary to add a static resources property to the * config in @link{{@link #setupResources(Configurable, RestConfig)}}, e.g. using something like * {@code * config.property(ServletProperties.FILTER_STATIC_CONTENT_REGEX, "/(static/.*|.*\\.html|)"); * } * * @return static resource collection */ protected ResourceCollection getStaticResources() { return null; } /** * add any servlet filters that should be called after resource * handling but before falling back to the default servlet */ protected void configurePostResourceHandling(ServletContextHandler context) {} /** * Returns a map of tag names to tag values to apply to metrics for this application. * * @return a Map of tags and values */ public Map<String,String> getMetricsTags() { return new LinkedHashMap<String, String>(); } /** * Configure and create the server. */ public Server createServer() throws RestConfigException { // The configuration for the JAX-RS REST service ResourceConfig resourceConfig = new ResourceConfig(); Map<String, String> metricTags = getMetricsTags(); configureBaseApplication(resourceConfig, metricTags); setupResources(resourceConfig, getConfiguration()); // Configure the servlet container ServletContainer servletContainer = new ServletContainer(resourceConfig); final FilterHolder servletHolder = new FilterHolder(servletContainer); server = new Server() { @Override protected void doStop() throws Exception { super.doStop(); Application.this.metrics.close(); Application.this.onShutdown(); Application.this.shutdownLatch.countDown(); } }; MBeanContainer mbContainer = new MBeanContainer(ManagementFactory.getPlatformMBeanServer()); server.addEventListener(mbContainer); server.addBean(mbContainer); MetricsListener metricsListener = new MetricsListener(metrics, "jetty", metricTags); List<URI> listeners = parseListeners(config.getList(RestConfig.LISTENERS_CONFIG), config.getInt(RestConfig.PORT_CONFIG), Arrays.asList("http", "https"), "http"); for (URI listener : listeners) { log.info("Adding listener: " + listener.toString()); NetworkTrafficServerConnector connector; if (listener.getScheme().equals("http")) { connector = new NetworkTrafficServerConnector(server); } else { SslContextFactory sslContextFactory = new SslContextFactory(); // IMPORTANT: the key's CN, stored in the keystore, must match the FQDN. // TODO: investigate this further. Would be better to use SubjectAltNames. if (!config.getString(RestConfig.SSL_KEYSTORE_LOCATION_CONFIG).isEmpty()) { sslContextFactory.setKeyStorePath( config.getString(RestConfig.SSL_KEYSTORE_LOCATION_CONFIG) ); sslContextFactory.setKeyStorePassword( config.getString(RestConfig.SSL_KEYSTORE_PASSWORD_CONFIG) ); sslContextFactory.setKeyManagerPassword( config.getString(RestConfig.SSL_KEY_PASSWORD_CONFIG) ); sslContextFactory.setKeyStoreType( config.getString(RestConfig.SSL_KEYSTORE_TYPE_CONFIG) ); if (!config.getString(RestConfig.SSL_KEYMANAGER_ALGORITHM_CONFIG).isEmpty()) { sslContextFactory.setSslKeyManagerFactoryAlgorithm( config.getString(RestConfig.SSL_KEYMANAGER_ALGORITHM_CONFIG)); } } sslContextFactory.setNeedClientAuth(config.getBoolean(RestConfig.SSL_CLIENT_AUTH_CONFIG)); List<String> enabledProtocols = config.getList(RestConfig.SSL_ENABLED_PROTOCOLS_CONFIG); if (!enabledProtocols.isEmpty()) { sslContextFactory.setIncludeProtocols(enabledProtocols.toArray(new String[0])); } List<String> cipherSuites = config.getList(RestConfig.SSL_CIPHER_SUITES_CONFIG); if (!cipherSuites.isEmpty()) { sslContextFactory.setIncludeCipherSuites(cipherSuites.toArray(new String[0])); } if (!config.getString(RestConfig.SSL_ENDPOINT_IDENTIFICATION_ALGORITHM_CONFIG).isEmpty()) { sslContextFactory.setEndpointIdentificationAlgorithm( config.getString(RestConfig.SSL_ENDPOINT_IDENTIFICATION_ALGORITHM_CONFIG)); } if (!config.getString(RestConfig.SSL_TRUSTSTORE_LOCATION_CONFIG).isEmpty()) { sslContextFactory.setTrustStorePath( config.getString(RestConfig.SSL_TRUSTSTORE_LOCATION_CONFIG) ); sslContextFactory.setTrustStorePassword( config.getString(RestConfig.SSL_TRUSTSTORE_PASSWORD_CONFIG) ); sslContextFactory.setTrustStoreType( config.getString(RestConfig.SSL_TRUSTSTORE_TYPE_CONFIG) ); if (!config.getString(RestConfig.SSL_TRUSTMANAGER_ALGORITHM_CONFIG).isEmpty()) { sslContextFactory.setTrustManagerFactoryAlgorithm( config.getString(RestConfig.SSL_TRUSTMANAGER_ALGORITHM_CONFIG) ); } } sslContextFactory.setProtocol(config.getString(RestConfig.SSL_PROTOCOL_CONFIG)); if (!config.getString(RestConfig.SSL_PROVIDER_CONFIG).isEmpty()) { sslContextFactory.setProtocol(config.getString(RestConfig.SSL_PROVIDER_CONFIG)); } connector = new NetworkTrafficServerConnector(server, sslContextFactory); } connector.addNetworkTrafficListener(metricsListener); connector.setPort(listener.getPort()); connector.setHost(listener.getHost()); server.addConnector(connector); } ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); context.setContextPath("/"); ServletHolder defaultHolder = new ServletHolder("default", DefaultServlet.class); defaultHolder.setInitParameter("dirAllowed", "false"); ResourceCollection staticResources = getStaticResources(); if (staticResources != null) { context.setBaseResource(staticResources); } if (config.getBoolean(RestConfig.ENABLE_GZIP_COMPRESSION_CONFIG)) { FilterHolder gzipFilter = new FilterHolder(AsyncGzipFilter.class); // do not check if .gz file already exists for the requested resource gzipFilter.setInitParameter("checkGzExists", "false"); gzipFilter.setInitParameter("methods", "GET,POST"); context.addFilter(gzipFilter, "/*", null); } String authMethod = config.getString(RestConfig.AUTHENTICATION_METHOD_CONFIG); if (enableBasicAuth(authMethod)) { String realm = getConfiguration().getString(RestConfig.AUTHENTICATION_REALM_CONFIG); List<String> roles = getConfiguration().getList(RestConfig.AUTHENTICATION_ROLES_CONFIG); final SecurityHandler securityHandler = createSecurityHandler(realm, roles); context.setSecurityHandler(securityHandler); } String allowedOrigins = getConfiguration().getString( RestConfig.ACCESS_CONTROL_ALLOW_ORIGIN_CONFIG ); if (allowedOrigins != null && !allowedOrigins.trim().isEmpty()) { FilterHolder filterHolder = new FilterHolder(CrossOriginFilter.class); filterHolder.setName("cross-origin"); filterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, allowedOrigins); String allowedMethods = getConfiguration().getString( RestConfig.ACCESS_CONTROL_ALLOW_METHODS ); if (allowedMethods != null && !allowedOrigins.trim().isEmpty()) { filterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, allowedMethods); } context.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST)); } context.addFilter(servletHolder, "/*", null); configurePostResourceHandling(context); context.addServlet(defaultHolder, "/*"); RequestLogHandler requestLogHandler = new RequestLogHandler(); Slf4jRequestLog requestLog = new Slf4jRequestLog(); requestLog.setLoggerName(config.getString(RestConfig.REQUEST_LOGGER_NAME_CONFIG)); requestLog.setLogLatency(true); requestLogHandler.setRequestLog(requestLog); HandlerCollection handlers = new HandlerCollection(); handlers.setHandlers(new Handler[]{context, new DefaultHandler(), requestLogHandler}); /* Needed for graceful shutdown as per `setStopTimeout` documentation */ StatisticsHandler statsHandler = new StatisticsHandler(); statsHandler.setHandler(handlers); server.setHandler(statsHandler); int gracefulShutdownMs = getConfiguration().getInt(RestConfig.SHUTDOWN_GRACEFUL_MS_CONFIG); if (gracefulShutdownMs > 0) { server.setStopTimeout(gracefulShutdownMs); } server.setStopAtShutdown(true); return server; } static boolean enableBasicAuth(String authMethod) { return RestConfig.AUTHENTICATION_METHOD_BASIC.equals(authMethod); } static ConstraintSecurityHandler createSecurityHandler(String realm, List<String> roles) { final ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler(); Constraint constraint = new Constraint(); constraint.setAuthenticate(true); constraint.setRoles(roles.toArray(new String[0])); ConstraintMapping constraintMapping = new ConstraintMapping(); constraintMapping.setConstraint(constraint); constraintMapping.setMethod("*"); constraintMapping.setPathSpec("/*"); securityHandler.addConstraintMapping(constraintMapping); securityHandler.setAuthenticator(new BasicAuthenticator()); securityHandler.setLoginService(new JAASLoginService(realm)); securityHandler.setIdentityService(new DefaultIdentityService()); securityHandler.setRealmName(realm); return securityHandler; } // TODO: delete deprecatedPort parameter when `PORT_CONFIG` is deprecated. // It's only used to support the deprecated configuration. public static List<URI> parseListeners( List<String> listenersConfig, int deprecatedPort, List<String> supportedSchemes, String defaultScheme ) { // handle deprecated case, using PORT_CONFIG. // TODO: remove this when `PORT_CONFIG` is deprecated, because LISTENER_CONFIG // will have a default value which includes the default port. if (listenersConfig.isEmpty() || listenersConfig.get(0).isEmpty()) { log.warn( "DEPRECATION warning: `listeners` configuration is not configured. " + "Falling back to the deprecated `port` configuration." ); listenersConfig = new ArrayList<String>(1); listenersConfig.add(defaultScheme + "://0.0.0.0:" + deprecatedPort); } List<URI> listeners = new ArrayList<URI>(listenersConfig.size()); for (String listenerStr : listenersConfig) { URI uri; try { uri = new URI(listenerStr); } catch (URISyntaxException use) { throw new ConfigException( "Could not parse a listener URI from the `listener` configuration option." ); } String scheme = uri.getScheme(); if (uri.getPort() == -1) { throw new ConfigException( "Found a listener without a port. All listeners must have a port. The " + "listener without a port is: " + listenerStr ); } if (scheme != null && supportedSchemes.contains(scheme)) { listeners.add(uri); } else { log.warn( "Found a listener with an unsupported scheme (supported: {}). Ignoring listener '{}'", supportedSchemes, listenerStr ); } } if (listeners.isEmpty()) { throw new ConfigException("No listeners are configured. Must have at least one listener."); } return listeners; } public void configureBaseApplication(Configurable<?> config) { configureBaseApplication(config, null); } /** * Register standard components for a JSON REST application on the given JAX-RS configurable, * which can be either an ResourceConfig for a server or a ClientConfig for a Jersey-based REST * client. */ public void configureBaseApplication(Configurable<?> config, Map<String, String> metricTags) { T restConfig = getConfiguration(); registerJsonProvider(config, restConfig, true); registerFeatures(config, restConfig); registerExceptionMappers(config, restConfig); config.register(new MetricsResourceMethodApplicationListener(metrics, "jersey", metricTags, restConfig.getTime())); config.property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true); } /** * Register a body provider and optional exception mapper for (de)serializing JSON in * request/response entities. * @param config The config to register the provider with * @param restConfig The application's configuration * @param registerExceptionMapper Whether or not to register an additional exception mapper for * handling errors in (de)serialization */ protected void registerJsonProvider( Configurable<?> config, T restConfig, boolean registerExceptionMapper ) { ObjectMapper jsonMapper = getJsonMapper(); JacksonMessageBodyProvider jsonProvider = new JacksonMessageBodyProvider(jsonMapper); config.register(jsonProvider); if (registerExceptionMapper) { config.register(JsonParseExceptionMapper.class); } } /** * Register server features * @param config The config to register the features with * @param restConfig The application's configuration */ protected void registerFeatures(Configurable<?> config, T restConfig) { config.register(ValidationFeature.class); } /** * Register handlers for translating exceptions into responses. * @param config The config to register the mappers with * @param restConfig The application's configuration */ protected void registerExceptionMappers(Configurable<?> config, T restConfig) { config.register(ConstraintViolationExceptionMapper.class); config.register(new WebApplicationExceptionMapper(restConfig)); config.register(new GenericExceptionMapper(restConfig)); } public T getConfiguration() { return this.config; } /** * Gets a JSON ObjectMapper to use for (de)serialization of request/response entities. Override * this to configure the behavior of the serializer. One simple example of customization is to * set the INDENT_OUTPUT flag to make the output more readable. The default is a default * Jackson ObjectMapper. */ protected ObjectMapper getJsonMapper() { return new ObjectMapper(); } /** * Start the server (creating it if necessary). * @throws Exception If the application fails to start */ public void start() throws Exception { if (server == null) { createServer(); } server.start(); } /** * Wait for the server to exit, allowing existing requests to complete if graceful shutdown is * enabled and invoking the shutdown hook before returning. * @throws InterruptedException If the internal threadpool fails to stop */ public void join() throws InterruptedException { server.join(); shutdownLatch.await(); } /** * Request that the server shutdown. * @throws Exception If the application fails to stop */ public void stop() throws Exception { server.stop(); } /** * Shutdown hook that is invoked after the Jetty server has processed the shutdown request, * stopped accepting new connections, and tried to gracefully finish existing requests. At this * point it should be safe to clean up any resources used while processing requests. */ public void onShutdown() { } }